使用C# 6.0

作者:[美]易格恩.阿格佛温(Eugene Agafonov)
译者:黄博文 黄辉兰
改编:陈广
日期:2018-3-21


之前的文章,并没有使用C#的最新语法,因为机房安装的是VS2012。估计我上课也不会讲到这里,所以从这篇文章开始,全部使用C#最新语法,如果开发窗体应用,也会使用VS2017。

简介

到现在为止,我们学习了任务并行库,这是微软提供的最新的异步编程基础设施。它允许我们以模块化的方式设计程序,来组合不同的异步操作。

遗憾的是,当阅读此类程序时仍然非常难理解程序的实际执行顺序。在大型程序中将会有许多相互依赖的任务和后续操作,用于运行其他后续操作的后续操作,处理异常的后续操作,并且它们都出现在程序代码中不同的地方。因此了解程序的先后执行变成了一个极具挑战性的问题。

另一个需要关注的问题是,能够接触用户界面控制器的每个异步任务是否得到了正确的同步上下文。程序只允许通过UI线程使用这些控制器,否则将会得到多线程访问异常。

说到异常,我们不得不使用单独的后续操作任务来处理在之前的异步操作中发生的错误。这又导致了分散在代码的不同部分的复杂的处理错误的代码,逻辑上无法相互关联。

为了解决这些问题,C# 5.0引入了新的语言特性,称为异步函数(asynchronous function)。它是TPL之上的更高级别的抽象,真正简化了异步编程。正如在之前提到的,抽象隐藏了主要的实现细节,使得程序员无须考虑许多重要的事情,从而使异步编程更容易。了解异步函数背后的概念是非常重要的,有助于我们编写健壮的高扩展性的应用程序。

要创建一个异步函数,首先需要用 async 关键字标一个方法。如果不先做这个,就不可能拥有 async 属性或事件访问方法和构造函数。代码如下所示:

async Task<string> GetStringAsync()
{
    await Task.Delay(TimeSpan.FromSeconds(2));
    return "Hello, World!";
}

另一个重要的事实是,异步函数必须返回 Task 或 Task 类型。可以使用 async void 方法,但是更推荐使用 async Task 方法。使用 async void 方法的唯一合理的地方是在程序中使用顶层UI控制器事件处理器的时候。

使用 async 关键字标注的方法内部,可以使用 await 操作符。该操作符可与TPL的任务一起工作,并获取该任务中异步操作的结果。在本章中稍后会讲述细节。在 async 方法外不能使用 await 关键字,否则会有编译错误。另外,异步函数在其代码中至少要拥有一个 await 操作符。然而,如果没有只会导致编译警告,而不是编译错误。

需要注意的是,在执行完 await 调用的代码行后该方法会立即返回。如果是同步执行,执行线程将会阻塞两秒然后返回结果。这里当执行完 await 操作后,立即将工作者线程放回线程池的过程中,我们会异步等待。2秒后,我们又一次从线程池中得到工作者线程并继续运行其中剩余的异步方法。这允许我们在等待2秒时重用工作者线程做些其他事,这对提高应用程序的可伸缩性非常重要借助于异步函数我们拥有了线性的程序控制流,但它的执行依然是异步的。这虽然好用,但是难以理解。本章将帮助你学习异步函数所有重要的方面。

以我的自身经验而言,如果程序中有两个连续的 await 操作符,此时程序如何工作有一个常见的误解。很多人认为如果在另一个异步操作之后使用 await 函数,它们将会并行运行。然而,事实上它们是顺序运行的,即第一个完成后第二个才会开始运行。记住这一点很重要,在本章中稍后会覆盖该细节。

关联 async 和 await 有一定的限制。例如,在C# 5.0中,不能把控制台程序的 Main 方法标记为 async。不能在 catch、finally、lock 或 unsafe 代码块中使用 await 操作符。不允许对任何异步函数使用 ref 或 out 参数。还有其他微妙的地方,但是以上已经包括了主要的需要注意的地方。C# 6.0 去除了其中一些限制,由于编译器内部进行了改进,可以在 catch 和 finally 代码块中使用 await 关键字。

异步函数会被C#编译器在后台编译成复杂的程序结构。这里我不会说明该细节。生成的代码与另一个 C# 构造很类似,称为迭代器。生成的代码被实现为一种状态机。尽管很多程序员几乎开始为每个方法使用 async 修饰符,我还是想强调如果本来无需异步或并行运行,那么将该方法标注为 async 是没有道理的。调用 async 方法会有显著的性能损失,通常的方法调用比使用 async 关键字的同样的方法调用要快上40~50倍。请注意这一点。

在本章中我们将学习如何使用 C# 中的 async 和 await 关键字实现异步操作。本章将讲述如何使用 await 按顺序或并行地执行异步操作,还将讨论如何在 lambda 表达式中使用 await,如果处理异常,以及在使用 async void 方法时如何避免陷阱。在本章结束前,我们会深入探究同步上下文传播机制并学习如何创建自定义的 awaitable 对象,从而无需使用任务。

使用 await 操作符获取异步任务结果

本节将讲述使用异步函数的基本场景。我们将比较使用 TPL 和使用 await 操作符获取异步操作结果的不同之处。

在Visual Studio Code中引入以下命名空间:

using System;
using System.Threading;
using System.Threading.Tasks;

运行以下代码:

static void Main(string[] args)
{
    Task t = AsynchronyWithTPL();
    t.Wait();

    t = AsynchronyWithAwait();
    t.Wait();
}

static Task AsynchronyWithTPL()
{
    Task<string> t = GetInfoAsync("Task 1");
    Task t2 = t.ContinueWith(task => Console.WriteLine(t.Result),
        TaskContinuationOptions.NotOnFaulted);
    Task t3 = t.ContinueWith(task => Console.WriteLine(t.Exception.InnerException),
        TaskContinuationOptions.OnlyOnFaulted);

    return Task.WhenAny(t2, t3);
}

static async Task AsynchronyWithAwait()
{
    try
    {
        string result = await GetInfoAsync("Task 2");
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex);
    }
}

static async Task<string> GetInfoAsync(string name)
{
    await Task.Delay(TimeSpan.FromSeconds(2));
    //throw new Exception("Boom!");
    return
        $"Task {name} is running on a thread id {Thread.CurrentThread.ManagedThreadId}." +
        $" Is thread pool thread: {Thread.CurrentThread.IsThreadPoolThread}";
}

运行结果:

Task Task 1 is running on a thread id 4. Is thread pool thread: True
Task Task 2 is running on a thread id 4. Is thread pool thread: True

当程序运行时运行了两个异步操作。其中一个是标准的TPL模式的代码,第二个使用了 C# 的新特性 async 和 await。AsynchronyWithTPL方法启动了一个任务,运行两秒后返回关于工作者线程信息的字符串。然后我们定义了一个后续操作,用于在异步操作完成后打印出该操作结果,还有另一个后续操作,用于万一有错误发生时打印出异常的细节。最终,返回了一个代表其中一个后续操作任务的任务(正常返回t2,出现异常返回t3),并等待其在 Main 函数中完成。

AsynchronyWithAwait方法中,我们对任务使用 await 并得到了相同的结果。这和编写通常的同步代码风格一样,即我们获取任务的结果,打印出结果,如果任务完成时带有错误则捕获异常。关键不同的是这实际上是一个异步程序。使用 await 后,C#立即创建了一个任务,其有一个后续操作任务,包含了 await 操作符后面的所有剩余代码。这个新任务也处理了异常传播。然后,将该任务返回到主方法中并等待其完成。

请注意根据底层异步操作的性质和当前异步的上下文,执行异步代码的具体方式可能会不同。稍后在本章中会解释这一点。

因此可以看到程序的第一部分和第二部分在概念上是等同的,但是在第二部分中 C# 编译器隐式地处理了异步代码。事实上,第二部分比第一部分更复杂,接下来我们将讲述细节。

请记住在 Windows GUI 或 ASP.NET 之类的环境中不推荐使用Task.WaitTask.Result方法。如果程序员不是百分百地清楚代码在做什么,很可能导致死锁。

取消对GetInfoAsync方法的throw new Exception代码行的注释,并在Main方法的最后加上Console.ReadLine();来测试异常处理是否工作。请生成.exe文件运行,结果如下图所示:

在lambda表达式中使用 await 操作符

本节将展示如何在 lambda表达式中使用 await。我们将编写一个使用了 await 的匿名方法,并且获取异步执行该方法的结果。

运行以下程序:

static void Main(string[] args)
{
    Task t = AsynchronousProcessing();
    t.Wait();
}

static async Task AsynchronousProcessing()
{
    Func<string, Task<string>> asyncLambda = async name =>
    {
        await Task.Delay(TimeSpan.FromSeconds(2));
        return
            $"Task {name} 运行中,线程 id {Thread.CurrentThread.ManagedThreadId}." +
            $" 是否线程池线程: {Thread.CurrentThread.IsThreadPoolThread}";
    };

    string result = await asyncLambda("async lambda");
    Console.WriteLine(result);
}

运行结果:

Task async lambda 运行中,线程 id 4. 是否线程池线程: True

首先,由于不能在 Main 方法中使用 async,我们将异步函数移到了AsynchronousProcessing方法中。然后使用 async 关键字声明了一个 lambda 表达式。由于任何 lambda 表达式的类型都不能通过 lambda 自身来推断,所以不得不显式向 C# 编译器指定它的类型。在本例中,该类型说明该 lambda 表达式接受一个字符串参数,并返回一个Task<string>对象。

接着,我们定义了 lambda 表达式体。有个问题是该方法被定义为返回一个Task<string>对象,但实际上返回的是字符串,却没有编译错误!这是因为 C# 编译器自动产生一个任务并返回给我们。

最后一步是等待异步 lambda 表达式执行并打印出结果。

对连续的异步任务使用 await 操作符

本节将展示当代码中有多个连续的 await 方法时,程序的实际流程是怎样的。我们将学习如何阅读有 await 方法的代码,以及理解为什么 await 调用的是异步操作。

运行以下代码:

static void Main(string[] args)
{
    Task t = AsynchronyWithTPL();
    t.Wait();

    t = AsynchronyWithAwait();
    t.Wait();
}

static Task AsynchronyWithTPL()
{
    var containerTask = new Task(() => { 
        Task<string> t = GetInfoAsync("TPL 1");
        t.ContinueWith(task => {
            Console.WriteLine(t.Result);
            Task<string> t2 = GetInfoAsync("TPL 2");
            t2.ContinueWith(innerTask => Console.WriteLine(innerTask.Result),
                TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);
            t2.ContinueWith(innerTask => Console.WriteLine(innerTask.Exception.InnerException),
                TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);
            },
            TaskContinuationOptions.NotOnFaulted | TaskContinuationOptions.AttachedToParent);

        t.ContinueWith(task => Console.WriteLine(t.Exception.InnerException),
            TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.AttachedToParent);
    });

    containerTask.Start();
    return containerTask;
}

static async Task AsynchronyWithAwait()
{
    try
    {
        string result = await GetInfoAsync("Async 1");
        Console.WriteLine(result);
        result = await GetInfoAsync("Async 2");
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine(ex);
    }
}

static async Task<string> GetInfoAsync(string name)
{
    Console.WriteLine($"Task {name} 开始!");
    await Task.Delay(TimeSpan.FromSeconds(2));
    if(name == "TPL 2")
        throw new Exception("Boom!");
    return
        $"Task {name} 正在运行,线程id {Thread.CurrentThread.ManagedThreadId}." +
        $" 是否线程池线程: {Thread.CurrentThread.IsThreadPoolThread}";
}

当程序运行时,与上节一样运行了两个异步操作。然而这次从AsynchronyWithAwait方法讲起。它看起来仍然像平常的同步代码,唯一不同之处是使用了两个 await 声明。最重要的一点是该代码依然是顺序执行的,Async 2 任务只有等之前的任务完成后才会开始执行。当阅读该代码时,程序流很清晰,可以看到什么先运行,什么后运行。但该程序如何是异步程序呢?首先,它不总是异步的。当使用 await 时如果一个任务已经完成,我们会异步地得到该任务结果。否则,当在代码中看到 await 声明时,通常的行为是方法执行到该 await代码行时将立即返回,并且剩下的代码将会在一个后续操作任务中运行。因此等待操作结果时并没有阻塞程序执行,这是一个异步调用。当AsynchronyWithAwait方法中的代码在执行时,除了在 Main 方法中调用t.Wait外,我们可以执行任何其他任务。然而,主线程必须等待直到所有异步操作完成,否则主线程完成后所有运行异步操作的后台线程会停止运行。

AsynchronyWithTPL方法模仿了AsynchronyWithAwait的程序流。我们需要一个容器任务来处理所有相互依赖的任务。然后启动主任务,给其加了一组后续操作。当该任务完成后,会打印出其结果。然后又启动了一个任务,在该任务完成后会依次运行更多的后续操作。为了测试对异常的处理,当运行第二个任务时故意抛出一个异常,并打印出异常信息。这组后续操作创建了与第一个方法中一样的程序流。如果用它与 await 方法比较,可以看到空更容易阅读和理解。唯一的技巧是请记住异步并不总是意味着并行执行。

对并行执行的异步任务使用 await 操作符

本节将学习如何使用 await 来并行地运行异步任务,而不是采用常用的顺序执行。

运行以下代码:

static void Main(string[] args)
{
    Task t = AsynchronousProcessing();
    t.Wait();
}

static async Task AsynchronousProcessing()
{
    Task<string> t1 = GetInfoAsync("Task 1", 3);
    Task<string> t2 = GetInfoAsync("Task 2", 5);

    string[] results = await Task.WhenAll(t1, t2);
    foreach (string result in results)
    {
        Console.WriteLine(result);
    }
}

static async Task<string> GetInfoAsync(string name, int seconds)
{
    await Task.Delay(TimeSpan.FromSeconds(seconds));
    //await Task.Run(() => Thread.Sleep(TimeSpan.FromSeconds(seconds)));
    return
        $"Task {name} 正在运行,线程 id {Thread.CurrentThread.ManagedThreadId}." +
        $" 是否线程池线程: {Thread.CurrentThread.IsThreadPoolThread}";
}

运行结果:

Task Task 1 正在运行,线程 id 4. 是否线程池线程: True
Task Task 2 正在运行,线程 id 4. 是否线程池线程: True

这里定义了两个异步任务,分别运行3秒和5秒。然后使用Task.WhenAll辅助方法创建了另一个任务,该任务只有在所有底层任务完成后才会运行。之后我们等待该组合任务的结果。5秒后,我们获取了所有结果,说明了这些任务是同时运行的。

然而这里观察到一个有意思的现象。当运行该程序时,你可能注意到这两个任务似乎是被线程池中的同一个工作线程执行的。当我们并行运行任务时怎么可能发生这样的事情呢?为了让事情更有趣,我们来注释掉GetInfoAsync方法中的await Task.Delay代码行,并解除对await Task.Run代码行的注释,然后再次运行程序。结果如下:

Task Task 1 正在运行,线程 id 3. 是否线程池线程: True
Task Task 2 正在运行,线程 id 5. 是否线程池线程: True

我们会看到该情况下两个任务会被不同的工作者线程执行。不同之处是Task.Delay在幕后使用了一个计时器,过程如下:从线程池中获取工作者线程,它将等待Task.Delay方法返回结果。然后Task.Delay方法启动计时器并指定一块代码,该代码会在计时器时间到了Task.Delay方法中指定的秒数后被调用。之后立即将工作者线程返回到线程池中。当计时器事件运行时,我们又从线程池中任意获取一个可用的工作者线程(可能就是运行一个任务时使用的线程)并运行计时器提供给它的代码。

当使用Task.Run方法时,从线程池中获取了一个工作者线程并将其阻塞几秒,具体秒数由Thread.Sleep方法提供。然后获取了第二个工作者线程并且也将其阻塞。在这种场景下,我们消费了两个工作者线程,而它们绝对什么事没做,因为在它们等待时不能执行任何其他操作。

我们将在之后讨论第一个场景的细节。到时会讨论用大量的异步操作进行数据输入和输出。尽可能地使用第一种方式是创建高伸缩性的服务器程序的关键。

处理异步操作中的异常

本节将描述在C#中使用异步函数时如何处理异常。我们将学习对多个并行的异步操作使用 await 时如何聚合异常。

运行如下代码:

static void Main(string[] args)
{
    Task t = AsynchronousProcessing();
    t.Wait();
    Console.ReadLine();
}

static async Task AsynchronousProcessing()
{
    Console.WriteLine("1. 单个异常");

    try
    {
        string result = await GetInfoAsync("Task 1", 2);
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"异常详细信息: {ex}");
    }

    Console.WriteLine();
    Console.WriteLine("2. 多个异常");

    Task<string> t1 = GetInfoAsync("Task 1", 3);
    Task<string> t2 = GetInfoAsync("Task 2", 2);
    try
    {
        string[] results = await Task.WhenAll(t1, t2);
        Console.WriteLine(results.Length);
    }
    catch (Exception ex)
    {
        Console.WriteLine($"异常详细信息: {ex}");
    }

    Console.WriteLine();
    Console.WriteLine("3. AggregateException中的多个异常");

    t1 = GetInfoAsync("Task 1", 3);
    t2 = GetInfoAsync("Task 2", 2);
    Task<string[]> t3 = Task.WhenAll(t1, t2);
    try
    {
        string[] results = await t3;
        Console.WriteLine(results.Length);
    }
    catch
    {
        var ae = t3.Exception.Flatten();
        var exceptions = ae.InnerExceptions;
        Console.WriteLine($"捕获异常: {exceptions.Count}");
        foreach (var e in exceptions)
        {
            Console.WriteLine($"异常详细信息: {e}");
            Console.WriteLine();
        }
    }

    Console.WriteLine();
    Console.WriteLine("4. catch 和 finally 块中的await");

    try
    {
        string result = await GetInfoAsync("Task 1", 2);
        Console.WriteLine(result);
    }
    catch (Exception ex)
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
        Console.WriteLine($"带 await 的 Catch 块: 异常详细信息: {ex}");
    }
    finally
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
        Console.WriteLine("Finally 块");
    }
}

static async Task<string> GetInfoAsync(string name, int seconds)
{
    await Task.Delay(TimeSpan.FromSeconds(seconds));
    throw new Exception($"Boom from {name}!");
}

生成.exe文件并运行,效果如下图所示:

我们运行了三个场景来展示在 C# 中使用 async 和 await 时关于错误处理的常见情况。第一种情况是最简单的,并且与常见的同步代码几乎完全一样。我们只使用 try/catch 声明即可获取异常细节。

一个很常见的错误是对一个以上的异步操作使用 await 时还使用以上方式。如果仍像第一种情况一样使用 catch 代码块,则只能从底层的 AggregateException 对象中得到第一个异常。

为了收集所有异常信息,可以使用 await 任务的 Exception 属性。在第三种情况中,我们使用 AggregateException的 Flatten 方法将将层级异常放入一个列表,并且从中提取出所有的底层异常。

为了演示 C# 6.0 中的改变,我们在异常处理代码的 catch 和 finally 代码块中使用了 await。为了验证在 C# 的早期版本中,在 catch 和 finally 代码块中是不可能使用 await 的,你可以在构建高级设置中指定项目属性编译采用 C# 5.0。

避免使用捕获的同步上下文

本文描述了当使用 await 来获取异步操作结果时,同步上下文行为的细节。我们将学习如何以及何时关闭同步上下文流。

本例使用vs2017的WPF应用程序进行编写。打开vs2017,新建一个WPF应用程序,在窗体上放置一个TextBlock控件,命名为tbResult。放置和一个Button控件,命名为btnRun。XAML代码如下:

<Window x:Class="WpfApp3.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:WpfApp3"
        mc:Ignorable="d"
        Title="MainWindow" Height="183.265" Width="449.539">
    <Grid>
        <TextBlock Name="tbResult" HorizontalAlignment="Left" Margin="30,24,0,0" TextWrapping="Wrap" Text="TextBlock" VerticalAlignment="Top" Height="63" Width="387"/>
        <Button Name="btnRun" Content="运行" HorizontalAlignment="Left" Margin="168,107,0,0" VerticalAlignment="Top" Width="105" RenderTransformOrigin="0.465,0.081" Height="24" Click="btnRun_Click"/>

    </Grid>
</Window>

双击生成按钮单击事件。加入如下命名空间:

using System;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Diagnostics;

代码更改如下:

private async void btnRun_Click(object sender, RoutedEventArgs e)
{
    tbResult.Text = "Calculating...";
    TimeSpan resultWithContext = await Test();
    TimeSpan resultNoContext = await TestNoContext();
    //TimeSpan resultNoContext = await TestNoContext().ConfigureAwait(false);
    var sb = new StringBuilder();
    sb.AppendLine($"With the context: {resultWithContext}");
    sb.AppendLine($"Without the context: {resultNoContext}");
    sb.AppendLine("Ratio: " +
        $"{resultWithContext.TotalMilliseconds / resultNoContext.TotalMilliseconds:0.00}");
    tbResult.Text = sb.ToString();
}
static async Task<TimeSpan> Test()
{
    const int iterationsNumber = 100000;
    var sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < iterationsNumber; i++)
    {
        var t = Task.Run(() => { });
        await t;
    }
    sw.Stop();
    return sw.Elapsed;
}

static async Task<TimeSpan> TestNoContext()
{
    const int iterationsNumber = 100000;
    var sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < iterationsNumber; i++)
    {
        var t = Task.Run(() => { });
        await t.ConfigureAwait(
            continueOnCapturedContext: false);
    }
    sw.Stop();
    return sw.Elapsed;
}

运行效果如下:

在本例中,我们将学习异步函数默认行为的最重要的方面之一。我们已经从第4章中了解了任务调度程序和同步上下文。默认情况下,await 操作符会尝试捕获同步上下文,并在其中执行代码。我们已经知道这有助于我们编写与用户界面控制器协作的异步代码。另外,使用 await 不会发生在之前章节中描述过的死锁情况,因为当等待结果时并不会阻塞UI线程。

这是合理的,但是让我们看看潜在会发生什么事。在本例中,当点击按钮时,运行了两个异步操作。其中一个使用了一个常规的 await 操作符,另一个使用了带false参数值的ConfigureAwait方法。false参数明确指出我们不能对其使用捕获的同步上下文来运行后续操作代码。在每个操作中,我们测量了执行完成花费的时间,然后将各自的时间和比例显示在窗体上。

结果看到常规的 await 操作符花费了更多的时间来完成。这是因为我们向UI线程中放入了成百上千个后续操作任务,这会使用它的消息循环来异步地执行这些任务。在本例中,我们无需在 UI 线程中运行该代码,因为异步操作并未访问 UI 组件。使用带false参数值的ConfigureAwait方法是一个更高效的方案。

还有一件事值得一提。尝试运行程序并只点击按钮然后等待结果,然后再这样做一次,但是这次点击按钮后尝试不停地拖拽应用程序窗口。你将注意到在捕获的同步上下文中代码执行速度变慢了!这个有趣的副作用完美演示了异步编程是多么危险。如果使用传统 Winodws 应用程序创建此程序,捕获同步上下文代码时根本无法拖动窗口.这个有趣的副作用完美演示了异步编程是多么危险。经历类似情况是非常容易的,而且如果你之前从未经历过这样的情况,那么几乎不可能通过调试来找出问题所在。

公平起见,让我们来看看相反的情况。在前面的代码片段中,在Click方法中取消注释的代码,并注释掉紧挨着它的前一行代码。当运行程序时,我们将得到多线程控制访问异常,因为设置 TextBlock 控制器文本的代码不会放置到捕捉的上下文中,而是在线程池的工作者线程中执行。

使用 async void 方法

本节描述了为什么使用 async void 方法非常危险。我们将学习在哪种情况下可使用该方法,以及如何尽可能地替代该方法。

运行如下代码:

static void Main(string[] args)
{
    Task t = AsyncTask();
    t.Wait();

    AsyncVoid();
    Thread.Sleep(TimeSpan.FromSeconds(3));

    t = AsyncTaskWithErrors();
    while(!t.IsFaulted)
    {
        Thread.Sleep(TimeSpan.FromSeconds(1));
    }
    Console.WriteLine(t.Exception);

    /*第一个被注释的代码块*/
    //try
    //{
    //	AsyncVoidWithErrors();
    //	Thread.Sleep(TimeSpan.FromSeconds(3));
    //}
    //catch (Exception ex)
    //{
    //	Console.WriteLine(ex);
    //}

    /*第二个被注释的代码块*/
    // int[] numbers = {1, 2, 3, 4, 5};
    // Array.ForEach(numbers, async number => {
    //     await Task.Delay(TimeSpan.FromSeconds(1));
    //     if (number == 3) throw new Exception("Boom!");
    //     Console.WriteLine(number);
    // });

    Console.ReadLine();
}

static async Task AsyncTaskWithErrors()
{
    string result = await GetInfoAsync("AsyncTaskException", 2);
    Console.WriteLine(result);
}

static async void AsyncVoidWithErrors()
{
    string result = await GetInfoAsync("AsyncVoidException", 2);
    Console.WriteLine(result);
}

static async Task AsyncTask()
{
    string result = await GetInfoAsync("AsyncTask", 2);
    Console.WriteLine(result);
}

static async void AsyncVoid()
{
    string result = await GetInfoAsync("AsyncVoid", 2);
    Console.WriteLine(result);
}

static async Task<string> GetInfoAsync(string name, int seconds)
{
    await Task.Delay(TimeSpan.FromSeconds(seconds));
    if(name.Contains("Exception"))
        throw new Exception($"Boom from {name}!");
    return
        $"Task {name} 正在运行,线程 id {Thread.CurrentThread.ManagedThreadId}." +
        $" 是否线程池线程: {Thread.CurrentThread.IsThreadPoolThread}";
}

生成.exe文件并运行,运行结果:

当程序启动时,我们通过调用AsyncTaskAsyncVoid这两个方法启动了两个异步操作。第一个方法返回一个 Task 对象,而另一个由于被声明为async void所以没有返回值。由于它们都是异步的所以都会立即返回。但是第一个方法通过返回的任务状态或对其调用Wait方法从而很容易实现监控。等待第二个方法完成的唯一方式是确切地等待多长时间,因为我们没有声明任何对象可以监控该异步操作的状态。当然可以使用某种共享的状态变量,将其设置到async void方法中,并从调用方法中检查其值,但返回一个 Task 对象的方式更好些。

最危险的部分是异常处理。使用async void方法,异常处理方法将被放置到当前的同步上下文中,在本例中即线程池中。线程池中未被处理的异常会终结整个进程。使用AppDomain.UnhandleException事件可以拦截未被处理的异常,但不能从拦截的地方恢复进程。为了重现该场景,可以取消 Main 方法中对 try/catch 代码块的注释,然后生成.exe文件并运行程序,运行结果:

关于使用 async void lambda 表达式的另一个事实是:它们与 Action 类型是兼容的,而 Action 类型在标准 .NET Framework 类库中的使用非常广泛。在 lambda 表达式中很容易忘记对异常的处理,这将再次导致程序崩溃。可以取消在 Main 方法中第二个被注释的代码块的注释来重现该场景。生成.exe文件并运行程序,运行结果:

强烈建议只在 UI 事件处理器中使用async void方法。在其他所有情况下,请使用返回 Task 的方法。